Skip to content

fix(#5255): read ANSI-C quoting ($'…') in the secret-read guard - #5285

Open
0xdhx wants to merge 58 commits into
open-gsd:nextfrom
0xdhx:fix/5255-secret-read-guard-ansi-c-quoting
Open

0xdhx wants to merge 58 commits into
open-gsd:nextfrom
0xdhx:fix/5255-secret-read-guard-ansi-c-quoting

Conversation

@0xdhx

@0xdhx 0xdhx commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Fix PR

Using the wrong template?
— Enhancement: use enhancement.md
— Feature: use feature.md


Linked Issue

Required. This PR will be auto-closed if no valid issue link is found.

Fixes #5255

The linked issue must have the confirmed-bug label. If it doesn't, ask a maintainer to confirm the bug before continuing.


What was broken

The secret-read guard had no model of bash's $'…' quoting. An escaped quote inside one closed the guard's quote early, the real closing quote then opened one that never ended, and every later command on the line was read as data: echo $'x\'y'; cat .env was allowed. A $'…' word was never decoded either, so cat $'.e\x6ev' was allowed.

What this fix does

  • $'…' spans end where the shell ends them. Under bash's and zsh's readings, tokenize, findParenClose, readHeredocTag and the ${…} reader end a span at its first unescaped quote.
  • A word is judged by its decoded value, under bash's and under zsh's. The two shells decode differently, and zsh is the Bash tool's shell on macOS. A word with no value to check is denied: ansi-c-unterminated when the quote never closes, ansi-c-undecodable for a literal NUL or a code point zsh refuses.
  • A run of $ is paired as each shell pairs it, before ', { and (. After an even run, a span zsh would read as unterminated or undecodable is not denied for that: echo $$'x\'; ls is allowed, as the issue's acceptance criterion says. zsh refuses that line and bash runs ls.
  • bash's $"…" is read as "…". zsh keeps the $.
  • A command holding $', $", $${, $$(, or a quote together with (( or $[, is scanned under bash's, zsh's and dash's readings as well as the plain one, and denied on any. A nested body is tested for those tokens again on its own. A command holding none of them, once \<newline> continuations are removed, is scanned at the top level by the plain reading alone. Note that the last trigger needs no $': arithmetic with a quote gets the extra readings too.

Scope, after the 2026-10-28 review and your two ruling comments since

Item Status Why
RangeError deny, lastSegment loop, scan work budget kept your first ruling
${…} reader, dash's reading, brace-tag and zsh arithmetic reader kept your first ruling: reverting any of these reopens one of the four shapes
per-reader step budgets kept your first ruling: Minor 4's rows stay with the code they test. Removing the budgets fails 17 rows
second-look scan (readAsBefore) kept your second ruling. Removing it fails 11 rows, among them reads that run in a shell and that next allows
main() refactor, require.main guard, module exports kept your second ruling. The RangeError arm and the in-process tests need them
<<${ tags, a ' in arithmetic, bare (( )), substitutions in arithmetic bodies kept your second ruling. The four shapes do not need them. They close neighbouring shapes that read the file in a shell
# comment rule in the $( ) end finder removed, tracked in #5945 no required shape needs it
$ before a line continuation opening a $( removed, tracked in #5945 no required shape needs it

The last two removals mean their shapes read the file in a shell and are allowed again, as they are on next. Sub-issue #5945 tracks them, as you ruled, and they stay on the pinned list. findSecretReadUnderOtherReadings is exported beside decodeAnsiC and findSecretRead so the frozen-scanner test can call both passes.

Root cause

All three quote-skipping scanners found the end of a single-quoted span with indexOf("'"), and none of them looked at the character before the quote. Every $ test in the file paired $ with ( or {.

Testing

How I verified the fix

The pushed head's last commit changes comments and pinned test rows only. The corpus, reversion and 14,923,366-command figures below were measured on code that is byte-identical to the pushed head once comments are stripped. A figure from another build says so.

Acceptance criteria

Criterion Where it is tested
The five commands from the issue are denied ANSI-C reading (bash, zsh) and a $'…' word is its decoded value blocks
Ordinary $'…' words stay allowed, and so does echo $$'x\'; ls ordinary dollar-quoted words stay allowed, and a run of $before' opens a span under zsh for the second
Unterminated $' or an undecodable escape is not allowed a $'…' word with no value to check is denied
All three scanners agree on span ends parity block: seeded properties

The four shapes. <<}; cat .env #, x$(($"));cat .env, echo ${x+$[ } ' ]}; cat $'.env' #' and the dash line-continuation form are denied. Each has rows that fail before and pass after. Their "Documented gaps" entries are gone.

Nothing the old scanner denied is allowed. tests/fixtures/secret-read-guard-base-scanner.cjs is next's scanner frozen at d47f196d1, and it needs the live hooks/lib, so it is a fixture and not a separate build. The property every command the frozen scanner denies, the hook denies compares it with the hook through both exported passes over 30,000 seeded commands, and fails if the frozen scanner denied fewer than 1,000 of them. That is evidence over a finite grammar, not a proof for every command.

The decoders against the shells. tests/fixtures/ansi-c-decode-oracle.json holds 1,200 spans with the bytes bash 5.2.21 and zsh 5.9 actually handed a child process, and a test checks both decoders against every one.

Corpus. Static scoring in process, nothing executed. "Base" is the frozen scanner above.

Real agent commands Generated commands
Commands 535,615 300,000
Denied by base 138 14,660
Denied here 140 41,318
Base denies, this head allows 0 0
Scanner threw 0 0

Against the previous PR head (829406b5d, rebased), the generated set has 1,253 newly allowed and 437 newly denied, and the real set has no change. All 1,253 ran in bash, dash and zsh with no read in that sample. That sample does not show every allowed command is read-free: the two removals above, and the listed gaps, are real reads the base also allows.

Reversion controls. 27 reversions of the code this round changed, several on the same mechanism, each fail named tests. The unmutated run of the same selection passes 102 of 102. Not every changed line has one: dropping the asBefore guard in zshMathEnd passes the file, and grepGlobVerdict has no reversion row.

Review. Seven passes by fresh Claude agents briefed by me, each executing commands in bash 5.2.21, dash 0.5.12 and zsh 5.9. Each refuted something. The first four found code defects, now fixed, and shapes now listed below. The last three found errors in prose only. The last review of a code change compared this code with the build before its last fix over 14,923,366 generated commands: nothing denied before is allowed now, and 1,206 more are denied. That comparison was against that build, not against next.

Stated costs

Regression test added?

  • Yes — added a test that would have caught this bug
  • No — explain why:

Platforms tested

  • macOS — by CI: this test file is on the macOS conformance tier, so conformance test (macos-latest, 24) runs it. The zsh behavior was measured in zsh 5.9 (Alpine), not on a macOS host.
  • Windows (including backslash path handling)
  • Linux
  • N/A (not platform-specific)

Runtimes tested

  • Claude Code
  • Antigravity
  • OpenCode
  • Other: the hook process fed PreToolUse payloads on stdin; shell behavior measured on bash 5.2.21, zsh 5.9 and dash 0.5.12
  • N/A (not runtime-specific)

Self-test report

{
  "schema": 1,
  "head_sha": "18f963b81da6899a8418835e2b38f1da061113ac",
  "tool": "npm-test",
  "failing_first": {
    "test": "tests/gsd-secret-read-guard.test.cjs",
    "fails_on_base": true,
    "passes_on_head": true
  },
  "commands": [
    "sh /home/dhx/.claude/dhx-upstream/tmp/dhx-upstream-revise-open-gsd__gsd-core-pr5285/ff-run.sh tests/gsd-secret-read-guard.test.cjs  # failing-first: base d47f196d1c65 → failed [blocks \"cat $'.env'\"; blocks \"<<${;cat .env\"; blocks \"((x' ));cat .env;''\"]; head 18f963b81da6 → passed; fresh detached worktrees built by their own revision (prep: npm run build:lib), dependencies: install, head test-side files overlaid on the base: 5, env -u CLAUDE_CONFIG_DIR HOME=<throwaway>",
    "mkdir -p /tmp/rv6st-5285-r13 && sh /home/dhx/.claude/dhx-upstream/tmp/dhx-upstream-revise-open-gsd__gsd-core-pr5285/mask/wsl-mask-run.sh env -u CLAUDE_CONFIG_DIR TMPDIR=/tmp/rv6st-5285-r13 TEST_CONCURRENCY=8 npm test  # full suite, env -u CLAUDE_CONFIG_DIR HOME=<throwaway>, at 18f963b81da6"
  ],
  "full_suite": {
    "command": "mkdir -p /tmp/rv6st-5285-r13 && sh /home/dhx/.claude/dhx-upstream/tmp/dhx-upstream-revise-open-gsd__gsd-core-pr5285/mask/wsl-mask-run.sh env -u CLAUDE_CONFIG_DIR TMPDIR=/tmp/rv6st-5285-r13 TEST_CONCURRENCY=8 npm test",
    "result": "passed",
    "passed": 49381,
    "failed": 0
  },
  "environment": {
    "os": "Linux 6.18.54.1-microsoft-standard-WSL2 (WSL2); Ubuntu 24.04.4 LTS",
    "node": "v24.21.0"
  },
  "output_excerpt": "base failure of blocks \"cat $'.env'\": cat $'.env': expected block (exit 2), got exit 0  0 !== 2  code: 'ERR_ASSERTION' name: 'AssertionError' expected: 2 actual: 0 operator: 'strictEqual' stack: |- assertBlocked (/home/dhx/.claude/dhx-upstream/tmp/dhx-upstream-revise-open-gsd__gsd-core-pr5285/self-test/18f963b81da6/ff/base-tree/tests/gsd-secret-read-guard.ff-base.test.cjs:67:10) TestContext.<anonymous> (/home/dhx/.claude/dhx-upstream/\nbase failure of blocks \"<<${;cat .env\": <<${;cat .env: expected block (exit 2), got exit 0  0 !== 2  code: 'ERR_ASSERTION' name: 'AssertionError' expected: 2 actual: 0 operator: 'strictEqual' stack: |- assertBlocked (/home/dhx/.claude/dhx-upstream/tmp/dhx-upstream-revise-open-gsd__gsd-core-pr5285/self-test/18f963b81da6/ff/base-tree/tests/gsd-secret-read-guard.ff-base.test.cjs:67:10) TestContext.<anonymous> (/home/dhx/.claude/dhx-upstrea\nbase failure of blocks \"((x' ));cat .env;''\": ((x' ));cat .env;'': expected block (exit 2), got exit 0  0 !== 2  code: 'ERR_ASSERTION' name: 'AssertionError' expected: 2 actual: 0 operator: 'strictEqual' stack: |- assertBlocked (/home/dhx/.claude/dhx-upstream/tmp/dhx-upstream-revise-open-gsd__gsd-core-pr5285/self-test/18f963b81da6/ff/base-tree/tests/gsd-secret-read-guard.ff-base.test.cjs:67:10) TestContext.<anonymous> (/home/dhx/.claude/dhx-u\nother base failures (not counted): 268 — regressions: #5255 — dollar-quoting ($'…' and $\"…\") > ANSI-C reading (bash, zsh): `\\'` does not close a $'…' span > blocks \"echo $'x\\\\'y'; cat .env\"; regressions: #5255 — dollar-quoting ($'…' and $\"…\") > ANSI-C reading (bash, zsh): `\\'` does not close a $'…' span > blocks \"git commit -m $'it\\\\'s' && cat .env\"; regressions: #5255 — dollar-quoting ($'…' and $\"…\") > ANSI-C reading (bash, zsh): `\\'` does not close a $'…' span > blocks \"x=$(printf $'\\\\''); cat .env\"; …\nfull suite: 30 summary blocks summed — tests 49490, pass 49381, fail 0, cancelled 0, skipped 108, todo 1; exit 0"
}

Checklist

  • Issue linked above with Fixes #NNN — PR will be auto-closed if missing
  • Linked issue has the confirmed-bug label
  • Fix is scoped to the reported bug — no unrelated changes included (scope as ruled; the table above names each item the 2026-10-28 review contested)
  • Regression test added (or explained why not)
  • All existing tests pass (npm test)
  • .changeset/ fragment added if this is a user-facing fix (npm run changeset -- --type Fixed --pr <NNN> --body "...") — or no-changelog label applied
  • No unnecessary dependencies added

Breaking changes

Three new deny codes on the Bash arm, ansi-c-unterminated, ansi-c-undecodable and command-too-complex, and a Grep glob with enough brace groups to overflow the brace expansion is now denied as glob-too-complex. Commands that were allowed only because the guard misread $'…' or $"…" are denied, outside the listed gaps. A command the scan cannot finish, by nesting deep enough to overflow the parser or by spending a work budget, is denied rather than allowed; next allows some of these.

Self-test note: the failing-first base run replaces require(HOOK_PATH) with an inert object in a copy of the test file, because a hook with no require.main guard exits the runner with code 0 on require. The three named rows spawn the hook as a process, so they test the real base hook, and the head runs the file unchanged. The full suite ran under a wrapper for this WSL2 host: it unsets WSL_DISTRO_NAME and WSL_INTEROP, bind-masks /proc/version, exports OUTER_UID, OUTER_GID and MASK_VERSION_FILE, and runs in a nested user and mount namespace, where supplementary groups collapse to nogroup, /proc/self/setgroups reads deny, and files the caller does not own appear as 65534:65534. uname -r and os.release() still read WSL2, and WSLENV is inherited. The run also sets TMPDIR, TEST_CONCURRENCY=8 and a throwaway HOME, and unsets CLAUDE_CONFIG_DIR.

@trek-e trek-e left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of head c444497f861b — changes requested

Summary. The fix does what the issue asks for the five listed commands (all now blocked, reproduced), but the new $-run handling in tokenize runs on every command, not only on $'…' ones, and it opens a bypass that the base blocks: cat $$\<newline> .env. Two further gaps in the same bug class remain allowed. Blocker 1, Major 2, Minor 6, Nit 5.

Classification and gates. #5255 carries bug + confirmed-bug (RULESET.CONTRIB.CLASSIFY.fix=requires confirmed-bug before implementation (legacy 'confirmed' label is back-compat only for duplicate-sweep exemption, not a valid implementation gate)). Fixes #5255, conventional title, not draft, no competing PR. Changeset type Security, pr: 5285, bold lead, (#5255); docs/USER-GUIDE.md updated; CHANGELOG.md untouched. No shipped workflow, agent or command changed (no compact-content baseline or Emitted-Drift-Ack-Growth requirement); no references/ or workflows/ file added.

Acceptance criteria, #5255 (Agent Brief and tests-to-add)

  1. deny echo $'x\'y'; cat .env — Met. 2. deny cat $'.e\x6ev' — Met. 3. deny git commit -m $'it\'s' && cat .env — Met. 4. deny x=$(printf $'\''); cat .env — Met. 5. deny cat <<$'EOF' heredoc then cat .env — Met. 6. allow echo $$'x\'; ls — Met. 7. the three scanners change together — Met (shared span-end function; seeded parity properties). 8. $$'x' stays a PID plus plain quote — Met for quote runs; not met for $ runs with a line continuation (Blocker 1). 9. $"…" — Met.

Reproduction (plain node spawning the real hook with a Bash payload on stdin; base = origin/next, head = this PR; exit 2 = block)

Command Base Head
cat $$\<newline> .env block allow
cat $\<newline>$\<newline> .env block allow
cat $$ .env block block
echo ${x:-$'}'}; cat .env, echo ${x//$'}'/y}; cat .env allow allow
echo $'x\'y'; cat .env, cat $'.e\x6ev' allow block (fixed)
cat $'.env\u', cat $'.env\x', cat $'.env\U' allow allow
echo $$'x\'; ls allow allow

Real shells on the review host: /bin/zsh -c "cat $'.env\u'" (and the \x, \U forms) prints the file; /bin/bash (3.2) reads a file literally named .env\u.

Findings

Blocker 1 — a command the base denies is now allowed [verified by running the hook] hooks/gsd-secret-read-guard.js, tokenize dollar branch, the "any other run" block (if (run.dollars > 1) { hasWord = true; buf += str.slice(i, run.end - 1); i = run.end - 1; continue; }): dollarRun consumes a \<newline> that follows the $ run, so run.end - 1 is the newline. The tokenizer resumes there, keeps the backslash in the word and treats the newline as a command separator, so .env becomes a bare command word with no operand. bash joins the lines (cat $$ .env). findSecretRead runs scanCommand(command, depth, true) for commands that contain no $' or $" at all, so this affects ordinary commands, contradicting "nothing a real command denied before can become allowed". The fuzz pass (about 5M mutated commands, base versus head) found only this class (cat$$\<nl> .env.local, the same inside <(…) and "$(…)"). Fix: append the run's $ characters without the continuation, or leave i on the last $; check findParenClose, which uses the same i = run.end - 1 shape; add deny rows and a property over $/continuation mixes.

Major 1 — $'…' inside ${…} still hides the rest of the line [verified] echo ${x:-$'}'}; cat .env and echo ${x//$'}'/y}; cat .env are allowed (bash runs cat .env), while echo "${x:-$'a'}"; cat .env and echo ${x:-$'\''}; cat .env are denied. ${ is taken whole up to the first }, which here is inside the dollar-quoted span. This is the failure the issue describes (a quoted character turns the guard off for the rest of the line) reached through a $'…' span; the source comment records it ("A $'…' inside ${…} is not read") but the changeset and USER-GUIDE text do not. It was allowed on the base too, so it is not a regression, but the change claims the guard now reads $'…' in words, $( ) and heredoc tags. Fix: give the ${ skipper the same span rule, or state the exclusion where users read it and pin it with a test.

Major 2 — zsh-only $'…' escapes still hide a secret name [verified: hook exit codes above; zsh and bash outputs on this host] The decoder models bash and keeps \u, \x, \U, \M- and \C- literally, so cat $'.env\u' is not seen as .env; zsh drops the backslash or truncates at the NUL and reads .env. Allowed on base and head alike. The PR header calls the zsh reading "unmeasured". The Bash tool's shell on macOS is zsh. Fix: decode under zsh rules as well, or deny a $'…' word that holds a bare \u, \x, \U, \M- or \C-.

Minor 1 — no test pins $ runs containing \<newline> [verified] The existing continuation rows only cover runs that reach a quote; this is the root of Blocker 1.

Minor 2 — no test pins the ${…} + $'…' case in either direction [verified] Neither a deny assertion nor a pinned-allow control with a comment that it is an accepted hole.

Minor 3 — the decoder has no committed oracle, and one comment is wrong [verified/judgment] The "18,066 spans against bash 5.2.21" comparison exists only in the PR body; decodeAnsiC has no in-repo oracle test, and the host bash here (3.2.57) already differs ($'.env\c'). The comment says decodeAnsiC models bash, but \x{H…} is a zsh form that bash 5.2 does not define; say which reading each form belongs to.

Minor 4 — the PR goes beyond the brief [judgment, Spec axis] The brief asks for bash's single reading; the PR adds a dual-reading scan (plain and ANSI-C), two new block codes (ansi-c-unterminated, ansi-c-undecodable), invalid-UTF-8 and literal-NUL denial (echo $'\xff', which the issue's "ordinary $'…' word with no secret operand remains allowed" would allow), extra decoder forms (\x{H…}, \c\\, a NUL-truncation second value) and about 400 lines beyond the brief. The PR body states the false-block cost (7 of 423,468 corpus commands) and asks whether the maintainer would prefer the other reading; that question is addressed to the maintainer and is not answered in this review.

Minor 5 — boundary rows are partial [verified] The 1 MiB $-run test covers exactly MAX_COMMAND_LENGTH with no limit-1 or limit+1 row, and nothing exercises the new ANSI-C paths at nesting depth 2, 3 and 4 (RULESET.TESTS.boundary-coverage).

Minor 6 — the property suite is a fixed sample and does not reach the decoder [verified] { seed: 5255, numRuns: 25 } covers a small fixed set of span shapes, and no property (round trip, idempotence, or decode-versus-oracle) targets decodeAnsiC, the parser here (RULESET.TESTS.property-based-testing); the \c, \x{…}, \U and \0 branches each need an assertion that kills them (Stryker 80% threshold, not measurable from the diff).

Nit 1 [verified] tests/gsd-secret-read-guard.test.cjs nulTruncation cases carry literal escape text in place of a dash inside // comments.
Nit 2 [verified] scanXargsPipe has a bare { const hit = wordSecret(w, true); if (hit) return hit; } block inside a loop, and the same one-liner at five call sites.
Nit 3 [judgment] findSecretRead now returns a string, an {code, text} object or null, and the caller discriminates with typeof; a uniform result type removes the sniffing.
Nit 4 [judgment] The ansiC flag is threaded through about ten signatures, and ansiCTagWord re-implements tokenize's word reader, so the word-reading rules live in three places and only the parity property keeps them aligned.
Nit 5 [judgment] nulValue and altText need a comment to say they hold zsh's truncated reading (zshValue/truncatedText), and the file header now carries a roughly 30-line known-gaps essay that belongs in docs.

Considered, not findings: $(echo $(echo $(echo cat .env))) (depth 3, MAX_NESTING_DEPTH) and cat ${x:-.env} are allowed on base and head and are not touched by this diff; no hang or quadratic cost found (about 1 MiB inputs finished in 0.06-0.46 s against 0.05-0.09 s on base); commits carry no Emitted-Drift-Ack-Growth trailer and none is required (the hook ships verbatim, not as an emitted baseline).

Passes run

Pass Result
/diagnose run; plain-node probe of the real hook, head versus base (table above)
/root-cause-analysis run; the three span scanners lacked a $' model and the fix addresses all three, but the $-run consumer resumes on a consumed newline (Blocker 1)
/qa-test-architect run; Minors 1, 2, 5, 6
/skills-from-the-artificer (bugfix-review) run; block below
/code-review run as two parallel sub-agents: Standards (Minors 5, 6, Nits 2-5, no hard violation) and Spec (Minor 4, Blocker 1 as a spec-conformance gap)
/security-review run: identification sub-agent found Major 2 (confidence 8, executed under zsh) and no additional PR-introduced bypass in about 5M fuzzed commands
Isolated adversarial pass run by a fresh agent with no prior context; found Major 1

Not run (Memtrace is unavailable): the Deterministic Review Pass (detect_changes, find_cross_module_issues, find_code_review_issues, find_yaml_rule_matches, review_github_pr), preflight_check, risk tiering (find_central_symbols, find_bridge_symbols), the recorded-decision check (recall_decision, governing_rules) and the co-change gate. None is reported clean. I did not execute any test (local node --test is not permitted); CodeGraph's index is the main checkout, so this PR's new symbols (dollarRun, ansiCQuoteEnd, decodeAnsiC, ansiCTagWord) are absent from it and the hook was reviewed from the diff and by execution.

Laws that apply

Subject: PR #5285 teaches the secret-read guard to read $'…' (ANSI-C) quoting in words, $( ) and heredoc tags, with a dual-reading scan.

  • kerckhoffs-principle — fired: a deny-list guard is safe only if it holds when its tokenizer is public, and a public input (cat $$\<newline> .env) now passes where it was blocked (tokenize, dollar-run branch). Action: Blocker 1.
  • postels-law — fired: the guard decodes one word several ways (plain, bash, zsh-truncated) and the shell it protects reads a fourth way for \u/\x; the decoder is lenient where it should reject. Action: Major 2, Minor 3.
  • leaky-abstractions — fired: the guard re-implements shell quoting, and the zsh/bash difference leaks through decodeAnsiC and ${…} handling. Action: Majors 1 and 2, Nit 4.
  • hyrums-law — fired: users' commands with $'\xff' or a literal NUL were allowed and are now denied (Minor 4); stated cost is 7 in 423,468 corpus commands. Action: Minor 4.
  • goodharts-law — fired: the parity properties pass on a fixed seed of 25 runs and cannot see the $-run class. Action: Minors 1, 6.
    Considered and cleared: knuths-optimization-principle (no optimization; cost measured and small), kernighans-law (comment mass is a Nit).

CI. At the passes snapshot every row on this head was SUCCESS or SKIPPED with nothing pending, and the branch is BEHIND next by one commit (rebase is a CI condition, not a finding). I am not approving: Blocker 1 and the Majors need a push.

CodeGraph Evidence

Memtrace unavailable: monthly quota exhausted and the server disconnected, so get_impact, get_symbol_context, recall_decision and find_code_review_issues were not run, and the recorded-decision check was not evaluated.

  • codegraph_impact tokenize: the change is confined to hooks/gsd-secret-read-guard.js (runs on every Bash/Read/Grep PreToolUse), its test and one USER-GUIDE line; scanner signatures gained an optional trailing ansiC parameter and internal callers were updated (read from the diff because the branch-new symbols are absent from the main-checkout index).
  • codegraph_callers/codegraph_node: file-mode read of the tokenize hunk was refused for this worktree path, so the hook was exercised by executing the extracted head and base copies instead.
  • Finding: the blast radius is one guard, but it is the secret-read guard, so a previously denied read becoming allowed (Blocker 1) is high-severity.

Verdict

Changes requested. Blocker 1, Major 2, Minor 6, Nit 5.

@trek-e trek-e added the review: changes requested PR reviewed — changes required before merge label Oct 10, 2026
@0xdhx
0xdhx force-pushed the fix/5255-secret-read-guard-ansi-c-quoting branch from c444497 to 41e055f Compare October 12, 2026 21:20
@0xdhx

0xdhx commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Every finding is addressed below except Nit 4, which I've declined with a reason. Minor 4 gets an answer rather than a code change. The branch is rebased onto next (d0b53f9f8), and the description is updated to match.

Findings → commits

  • Blocker 1 (cat $$\<newline> .env) → 0c09bba46. The fix keeps dollarRun's last $ (run.last), and all four consumers (both tokenize branches, findParenClose, ansiCTagWord) now stop there and take the run's text as '$'.repeat(n). findSecretRead now runs the plain reading alone unless the command, with its \<newline>s joined, contains $' or $". That restores "a command without dollar-quoting gets the pre-bug(hooks): secret-read-guard has no model of ANSI-C quoting — echo $'x\'y'; cat .env and cat $'.e\x6ev' are allowed #5255 verdict" by construction rather than by assertion. Your fuzz shapes are rows, plus a seeded property over $/continuation mixes.
  • Major 1 (${x:-$'}'}) → 30db084ff. Under the ANSI-C readings, ${ ends at the first } outside a $'…' span. The text inside ${…} is still not scanned (out of scope in the brief), so cat ${x:-$'.e\x6ev'} is pinned as an accepted hole, the same as cat ${x:-.env}.
  • Major 2 (zsh escapes) → 0921664a8. A dollar-quoted command is now read under bash's decoding, zsh's decoding (a model of zsh 5.9's getkeystring), and the plain reading, and is denied if any of them finds a read.
    • All five forms you listed are denied. \u, \x, \U and \C- are deny rows. A trailing \M- (cat $'.env\M-', which zsh 5.9 reads as .env) is denied, but has no row of its own yet; I'll add one with the next change. \M- before a character yields a byte ≥ 0x80, which cannot spell a protected name, and echo $'\M-a'; ls is pinned as allowed.
    • Writing the zsh decoder turned up three more forms that your list didn't name, all allowed on base and on the previous head:
      • cat $'.env.\local': zsh drops the backslash of an unknown escape.
      • cat $'.env\C-@': zsh reads it as a NUL that cuts the argument.
      • a heredoc tag that zsh decodes differently from bash (cat <<$'\.E'), which ends the heredoc on a different line.
    • Each is a deny row.
  • Minor 1, Minor 2 → rows in 0c09bba46 and 30db084ff.
  • Minor 3 → 0921664a8.
    • The committed oracle is tests/fixtures/ansi-c-decode-oracle.json: 1,200 spans with the bytes real bash 5.2.21 and zsh 5.9 handed a child process, checked in-process against both decoders.
    • A correction: \x{H…} is bash's form, not zsh's. bash 5.2.21 reads $'\x{41}' as A, and zsh reads \x{ as a NUL. The code comment now says which shell each form belongs to.
    • On bash 3.2: I measured 3.2.57 on 4,000 generated spans. Every value that differed from 5.2's still held a backslash or a control byte, which no protected name contains. It is listed as not modelled.
  • Minor 5, Nit 1 → 983596b4e. Rows at 1 MiB −1 / ±0 / +1 for a bare $ run and for one after $'x', and denies at nesting depths 2 and 3. Depth 4 is pinned as allowed, the same cutoff as the plain reading.
  • Minor 6 → 0921664a8. In-process round-trip and identity properties over both decoders, 2,000 runs each. The spawn-based parity properties stay at 25 runs because each run spawns the hook.
  • Nit 2, Nit 5 → 0921664a8. wordSecret, altText and nulValue are gone, replaced by the per-shell reading. The header essay is now a pointer to the dollar-quoting section, which lists what the readings do not reach.
  • Nit 3 → 41e055f0f. Every Bash block is { code, text } or null.

Also corrected: the USER-GUIDE said that text after an escaped NUL is blocked outright. That was false: a literal NUL is what blocks. Fixed in 30db084ff.

Declined: Nit 4 (the ansiC threading; ansiCTagWord re-implementing the word reader). The flag is now a reading identifier ('bash', 'zsh' or plain) rather than a boolean. Keeping the plain reading as the untouched pre-#5255 code path is what makes "nothing denied before is allowed now" checkable by reading findSecretRead. Folding the readings into one word reader would put that invariant back behind a test. If you'd still rather have the restructure, I'll do it as a follow-up PR.

Minor 4 (scope), answered with no code change.

  • The undecodable deny comes from the brief's acceptance criterion ("Unterminated $' or an undecodable escape does not result in allow"). You're right that \xff is itself a valid escape: the PR reads "undecodable" as "the value is not text" (bytes that are not UTF-8), because the guard compares names as text. That is my reading, not the brief's plain wording. If you read it as escape-level only, $'\xff' should be allowed and I'll switch bash's decoding to lenient. The corpus cost of the current reading is 9 false blocks, given in the description.
  • The plain reading is kept beside bash's because sh is dash on Debian and Ubuntu. Reading bash's way alone would allow, in an sh -c body, a read the guard denies today (row 2 of the table in the description).
  • The extra decoder forms are the shells' own, measured against the oracle above.
  • If you'd rather review the zsh reading separately, I can split it into its own PR.

Still open, not changed here: echo "$(cat <<"E\<newline>OF"…)"; cat .env is allowed on next and on this branch. Inside $( ), the heredoc-tag reader doesn't join a \<newline> inside a double-quoted tag the way bash does, so the heredoc swallows the rest of the line. It predates #5255 and was in the old header's gap list, which this round's header rewrite dropped by mistake. The scanner involved is the one #5309 changes, so I'll fix it, and restore the note, there.

Verification. Reverting each of the three fixes alone fails named tests: 7 rows plus the property for Blocker 1, 4 rows for Major 1, and 11 rows for Major 2. The commit sequence is scoped and bisectable: the guard's test file passed at every commit (runner exit 0), and none of its tests went unrun at any of them. In a corpus of 464,129 real agent commands, nothing denied on next is allowed now. 42 check-run registrations on 41e055f0f as of 21:51Z: 39 SUCCESS, 3 SKIPPED, none failed.

@0xdhx
0xdhx force-pushed the fix/5255-secret-read-guard-ansi-c-quoting branch from 41e055f to eba8dde Compare October 13, 2026 19:40
@0xdhx

0xdhx commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto next (19b16924f) to clear the conflict with #5214, and added the \M- row I owed from the last round. No other code changed.

Rebase. #5214 added its debugger-cli-spawn check to the same hook and test file, so three of the seven commits needed hand resolution. Every conflict was the two changes inserting at the same spot, and the resolution keeps both:

#5214 doesn't touch tokenize, findSecretRead or the scanners they call, so the corpus figures in the description still describe the scan next runs.

The \M- row → eba8ddee8. It pins cat $'.env\M-'. The zsh decoder reads that as .env, dropping the trailing modifier. That reading comes from the zsh 5.9 model the oracle fixture checks; I have no zsh here to run these three strings live. The same commit pins \C- and a dashless \M, which take the same branch and also had no row. A mutant that keeps a trailing modifier literal, as bash does, passes every other test in the file and fails exactly these three.

Noticed, not changed here. #5214's check reads words the plain way, so claude --agent $'gsd-debugger' -p x is allowed on next and on this branch alike (hook exit 0 on both). It's outside this PR. If you want it closed, I can open an issue for it.

Verification.

  • The guard's test file passes at every commit of the rebased sequence (each built from its own sources), with no test newly unrun at any of them.
  • Against the rebased tree, the reader tests of the three files next also changed show no pass→fail change: 3,415 results before the rebase and 3,520 after, 0 failing at either end. The 51 newly skipped results are OpenCode live-binary tests that next added, and they need an OpenCode binary.
  • The description's negative-control figure is updated: against the hook on next, 75 of the 504 hook-level tests fail, and all 504 pass here.
  • 42 check-run registrations on eba8ddee8 as of 20:10Z: 39 SUCCESS, 3 SKIPPED.

@trek-e trek-e left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of head eba8ddee82a9 - substance clean pending maintainer decisions

Prior findings. Re-ran the hook (head vs next): cat $$\<nl> .env, cat $\<nl>$\<nl> .env blocked (Blocker 1 fixed); echo ${x:-$'}'}; cat .env, echo ${x//$'}'/y}; cat .env blocked (Major 1); $'.env\u', \x, \U, \M-, $'.env.\local', \C-@ blocked (Major 2). Minors 1-3, 5, 6 and Nits 1-3, 5 are addressed (rows, oracle fixture, boundary and depth rows, in-process properties, uniform result). Nit 4 declined with a reason I accept.

Open for decision.

  • echo $'\xff'; ls is blocked (ansi-c-undecodable); the issue wording would allow it. Keep (9 corpus false blocks) or make bash decoding lenient?
  • The zsh reading and oracle could be split out, as the author offered. Keep in one PR?
  • claude --agent $'gsd-debugger' bypassing #5214's check predates this PR; file a separate issue?

Nits. Changeset is a ~1.2k-char paragraph; shorten it. The \M-/\C-/\M hook rows are model-derived (no live zsh); say so in a test comment or confirm on a zsh host.

State. Fixed against next by the 8 commits; branch is BEHIND by 15, merges clean; rebase and push. #5309 touches the same hook and tests, so the second to merge will need a rebase. CI on this head: no failures, nothing pending.

Memtrace Evidence

The Memtrace MCP server was unavailable for this review (it failed to connect: memtrace not found in PATH), so no graph-backed check ran. None of the four checks below is a clean result.

  • get_impact: not run. Blast radius comes from reading the diff and grepping callers.
  • get_symbol_context: not run. Callers and callees of the changed symbols were checked by grep.
  • recall_decision: not run. The recorded-decision gate did not execute, so this does not show that no decision governs the change. ADRs and CONTEXT.md predicates were checked by grep instead.
  • find_code_review_issues: not run. The deterministic review pass did not run. A manual diff review stood in for it, so the findings above are judgment, not graph facts.

@0xdhx
0xdhx force-pushed the fix/5255-secret-read-guard-ansi-c-quoting branch from eba8dde to 5565152 Compare October 15, 2026 06:45
@0xdhx

0xdhx commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the re-review. The branch is rebased onto next (2758ee980; no conflicts, and nothing in that range touches this PR's files), and the description is updated to match.

Open for decision

  • echo $'\xff'; ls → switched to lenient, 8d6b40f69. bash's reading now decodes a $'…' value the way zsh's already did: a byte that is not UTF-8 becomes U+FFFD, which no protected name holds, and every ASCII byte keeps its place, so cat $'x\xc3/.env' and cat $'.env.\xff' are still reads. For \u/\U the decoder now writes the bytes bash 5.2 writes: the UTF-8 pattern for every value below 2^31, surrogates included, measured at each length boundary. ansi-c-undecodable now covers only a literal NUL and a code point zsh refuses ($'\ud800'; zsh stops the line at that word). On the command corpus, the 9 false blocks are allowed now, nothing next denies is allowed, and the one command still newly denied is an ansi-c-unterminated that bash -n rejects too.
  • Splitting out the zsh reading → I'd keep it in this PR. Split out, Major 2 (cat $'.env\u' reads .env under zsh, the Bash tool's shell on macOS) would stay open on next until the second PR merged. If you'd still rather review it separately, I'll split it.
  • claude --agent $'gsd-debugger' → yes, I'll open a separate issue for bug(debug): the #4395 blocking-spawn fix is present but not honored — session-manager still backgrounds its gsd-debugger on 1.15.0, the checkpoint never advances #5214's check.

Nits

  • Changeset → 5565152b9, now about 400 characters.
  • Model-derived \M- / \C- / \M rows → 39d3982d1. I ran every row in the zsh-only table in zsh 5.9, in an Alpine 3 container, and cat printed the file for all 15. The test comment says so. The oracle fixture never puts an escape at the end of a span, so I also read each of 41 escape forms placed last in $'.env…' from bash 5.2.21 and zsh 5.9. 14 read .env under at least one shell, and the hook blocks all 14 (the hook on next blocks none of them). Six had no row and now do.

#5309. Noted: whichever of the two lands second gets rebased over the other.

Verification

  • The sequence is scoped and bisectable: the guard's test file passes at every commit (each built from its own sources), and no test of it is newly unrun at any of them.
  • Restoring the hook from before 8d6b40f69 fails 10 named tests. A decoder mutant that drops the character after a bad byte fails the x\xc3/ decoder row by name. The hook-level row alone can't catch that mutant, because the zsh and plain readings each still deny the command.
  • At the rebased tip: the 5 test files next added pass 44/44, and the 15 it changed pass 2,152 with 0 failing. 3 of their newly run tests skip on my host, which has no zsh binary (the zsh runs above were in the container).
  • 42 check-run registrations on 5565152b9 as of 07:19Z: 39 SUCCESS, 3 SKIPPED, none failed.

@trek-e trek-e left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — fix(#5255): read ANSI-C quoting ($'…') in the secret-read guard

Head reviewed: 5565152b90ef23d830fd632c60d792d5b4291af4 (fresh gh pr view this turn: mergeable=MERGEABLE, mergeStateStatus=BEHIND, reviewDecision=CHANGES_REQUESTED). Re-review of trek-e's two CHANGES_REQUESTED reviews (c444497f861b, eba8ddee82a9) and the author's three replies.

Summary

Every item from the earlier reviews is fixed on this head (re-verified by running the real hook, see below). The ANSI-C work itself held up under about 100k differential-fuzz commands run through real zsh 5.9 and bash 3.2 (0 bypasses attributable to $'…' decoding). Two live bypasses of the exact bug class remain, both verified against a real shell and both also present on next: (1) in zsh, $$'…' is PID plus an ANSI-C span, not PID plus a plain quote, so echo $$'\''; cat .env reads .env and the hook allows it; (2) in bash, $${ is PID plus {, but the hook swallows the line as an unterminated ${, so echo $${; cat .env reads .env and is allowed. The PR text and USER-GUIDE claim zsh is modelled and that $$'x' is "the PID and a plain quote"; for zsh that is false. Changes requested: 2 Major, 3 Minor, 1 Nit.

Classification & gate compliance

  • Issue #5255 carries bug + confirmed-bug (Bug-Fix track). PR has label review: changes requested. Title fix(#5255): … conventional. Not draft. Body has Fixes #5255, follows the Fix template. Branch fix/5255-secret-read-guard-ansi-c-quoting conforms.
  • Changeset .changeset/quiet-foxes-quote.md: type Security, pr: 5285, bold lead, trailing (#5255), ~400 chars (earlier nit fixed). CHANGELOG.md untouched. Security type needs no docs/ update; docs/USER-GUIDE.md line updated anyway (see Minor 1 for an inaccurate sentence in it).
  • No references/ or workflows/ file added (no inventory drift); no shipped workflow/agent/command changed. install-tree fixtures list the hook by name only, so no fixture co-change.
  • Template "Platforms tested": only Linux ticked; macOS unchecked although the zsh behaviour is the macOS Bash tool's shell. zsh checks were run in an Alpine container (per author). See Minor 3.
  • Scope: one concern (the secret-read guard). The dual-reading design and the zsh decoder are beyond the issue brief but are disclosed and justified; see decisions below.

Must-Have Acceptance Checklist (issue #5255 Agent Brief / tests-to-add)

Re-run on this head via the real hook (exit 2 = deny):

  1. echo $'x\'y'; cat .env denied: PASS (exit 2; allowed on base).
  2. cat $'.e\x6ev' denied: PASS (exit 2; allowed on base).
  3. git commit -m $'it\'s' && cat .env denied: PASS (author's test row; same shape echo $'a'$'\''; cat .env exit 2).
  4. x=$(printf $'\''); cat .env denied: PASS (echo $(echo $'\'') ; cat .env exit 2, depth 2 and 3 also exit 2).
  5. cat <<$'EOF' heredoc then cat .env denied: PASS (cat <<$'E\'F'… exit 2).
  6. echo $$'x\'; ls stays allowed: PASS on the hook (exit 0), BUT see Major 1: under zsh that command is an unterminated ANSI-C span, so criterion 6 is only true for bash's reading. It conflicts with the zsh model.
  7. Unterminated $' or an undecodable escape is not allowed: PASS (cat $'.env exit 2; $'\ud800' / literal NUL covered).
  8. All scanners agree on span ends: PASS by construction (shared span-end rule, seeded parity properties).
  9. $"…" read as "…" (optional item 5): PASS (cat $".env" exit 2).

Root-cause / functional verdict

Root cause (one sentence): three quote-skipping scanners ended a single-quoted span at the next ' without looking at the preceding backslash, and no $ test paired $ with ', so $'…' was never modelled. The diff addresses that root cause in all three scanners (plus the ${…} skipper) rather than special-casing the five commands.

Open items remaining (not the fixed ones):

  • The zsh reading is not complete: it models zsh's $'…' decoder but not zsh's rule that a $ run before ' always opens an ANSI-C span (Major 1).
  • The $-run pairing the PR introduces is applied before ' but not before { (Major 2).

Previously reported items, re-verified fixed on this head: Blocker 1 (cat $$\<nl> .env) denied; Major 1 (echo ${x:-$'}'}; cat .env) denied; Major 2 ($'.env\u', \x, \U, \M-, .env.\local, \C-@) all denied; \xff now lenient.

Decisions the author left open: evaluated against the guard's fail-closed posture

Maintainer rulings: item 2 — keep the zsh reading in this PR. Item 3 — the claude --agent $'gsd-debugger' bypass is tracked separately in #5441 and is not required here. Item 1 — recommendation below stands (keep lenient).

  1. echo $'\xff'; ls lenient vs strict. Recommendation: KEEP LENIENT (as now implemented in 8d6b40f69). Verified: echo $'\xff'; ls exit 0; cat $'\xff'/.env and echo $'\xff'; cat .env still exit 2; cat $'x\xc3/.env'-style reads remain reads because ASCII bytes keep their place and a non-UTF-8 byte becomes U+FFFD, which no protected name (.env, .env.*, .secrets, matched case-insensitively) can contain. Leniency here changes only the decoded value compared, never whether the rest of the line is scanned, so it does not open a skip. Denying would be a false block with no security value (9 corpus commands). The remaining ansi-c-undecodable denies (literal NUL, code point zsh refuses) are correct fail-closed: there the cut changes which name the shell sees.
  2. Split the zsh reading into its own PR. Recommendation: KEEP IN ONE PR. zsh is the Bash tool's shell on macOS; Major 2 of the first review (cat $'.env\u' reading .env under zsh) was real and verified, and splitting would leave a known live bypass on next until a second PR merges. The zsh reading is also where the remaining gap is (Major 1 below); that is a reason to fix it in place, not to defer it. Reviewability cost is mitigated by the committed 1,200-span oracle.
  3. claude --agent $'gsd-debugger' bypassing #5214's debugger-cli-spawn check. Recommendation: SEPARATE ISSUE, which now exists as #5441. Different contract (a workflow-integrity check, not secret reads), different file section; one-concern-per-PR applies. Note for the maintainer: CLAUDE.md "No Deferrals" says a defect found anywhere is fixed in the current change; that rule and "One concern per PR" conflict here and the call is the maintainer's.

Blast radius

hooks/gsd-secret-read-guard.js runs on every Read/Grep/Bash PreToolUse. Exports gained { decodeAnsiC, findSecretRead } (new, for in-process tests); no existing consumer imports the hook. Scanner signatures gained a trailing reading parameter; all call sites are inside the file. findSecretRead now returns {code,text}|null (previously string|null) uniformly.
Branch is BEHIND next by 64 commits. git merge-tree reports no conflict; a trial merge (origin/next fb65ab92b0 + PR head, scratch worktree, not committed) merges cleanly. next gained #5478 (scanGitConfig, walkGitGlobals) in the same hook, which calls findSecretRead(script, depth+1). I exercised the merged hook: git -c alias.x=$'!cat .env' x, …$'!cat .e\x6ev', …$'!echo \'a\'; cat .env', git -c $'alias.x=!cat .env' x, git -c diff.external='cat .env' diff all exit 2; git status exit 0. No semantic break found. Rebase is still required (BEHIND) and #5478's tests must run on the rebased tree (CI condition, not a finding).

Co-change completeness

Hook + test + fixture oracle + USER-GUIDE + changeset all present. docs/INVENTORY*, hooks/hooks.json, scripts/build-hooks.js, install-tree fixtures reference the hook by name only and need no change. CONTEXT.md has no new vocabulary. No gap found.

Test quality

  • No wall-clock, no source-grep (the test reads a JSON fixture only), fast-check properties present (6 spawn-based parity properties at 25 runs; 2 in-process decoder properties at 2,000 runs; a $/continuation property at 60). Boundary rows at 1 MiB -1/0/+1 and nesting depth 2/3/4 present per author and prior review.
  • Fail-first: author reports 75 of 504 hook-level rows fail against next hook and 504 pass on head; plausible (the five issue commands plus decoded-name rows are allowed on base, confirmed by my base/head table). I could not execute the suite (local node --test is blocked; CI is the executor).
  • Gap: no row for the two bypasses below, and echo $$'x\'; ls is pinned as allowed (bash-only semantics) without a zsh counter-row.

Memtrace Evidence

  • get_impact: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT).
  • get_symbol_context: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT).
  • recall_decision: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). No governing-decision check ran; CLAUDE.md/CONTEXT.md/CONTRIBUTING.md were checked manually.
  • find_code_review_issues: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). The deterministic review pass did not run; findings below are manual and executed-evidence based.

Substitute evidence (CodeGraph was unusable for the worktree: "isn't indexed"; the main-checkout index predates the PR's symbols). Instead I executed the real hook from detached worktrees of head (5565152b9), origin/next (f7efbf110a) and a trial merge, against: ~170 hand-built shapes; a decoder-escape differential fuzz (about 40k commands, seeds 1-4, each run in real zsh -f -c and bash --norc -c, flagged when a shell printed the secret file and findSecretRead returned null): 0 bypasses; and a quote/paren/heredoc "token soup" fuzz (200k commands, 11 seeds) that found 60 distinct allowed-but-executed commands, all of which next also allows (0 regressions: none denied on base and allowed on head). Shells on the review host: zsh 5.9, bash 3.2.57 (not 5.2; escapes where 3.2 differs were not used as evidence).

Findings

Major 1 — zsh reads $$'…' as PID + ANSI-C span; the guard reads it as PID + plain quote, so an escaped quote hides the rest of the line [deterministic fact; pre-existing on next, but exactly the bug class of #5255 and contradicts the PR's zsh claims] hooks/gsd-secret-read-guard.js (dollarRun / $$ pairing in tokenize, findParenClose, ansiCTagWord, applied to the zsh reading).
Repro (hook exit codes head=base=0; real zsh 5.9 with a .env in cwd prints it): echo $$'\''; cat .env; also echo $$'\''a<newline>cat .env, echo x$$'\''; cat .env. zsh output of echo $$'\''F is <pid>'F (the span closes); bash errors with "unexpected EOF while looking for matching ''". Other $-prefixed forms ($@', $*', $1', $!', $#', $?', ${x}', $x', $HOME') are NOT affected (checked in zsh: not ANSI-C). Only a $run: the last$of a run before'opens ANSI-C in zsh even when the run is$$. Fix: in the zsh reading, treat a $run followed by'as ending in a$'opener (the run's last$pairs with the quote); keep the pairing-left-to-right rule for the bash and plain readings. Consequence to decide: under the zsh reading</code>echo $$'x'; ls<code> becomes an unterminated span (zsh itself errors on it), so acceptance criterion 6 only holds for bash; either the zsh reading denies it (</code>ansi-c-unterminated<code>, stricter, arguably correct since zsh never runs </code>ls<code>) or criterion 6 is restated as bash-only. Add rows: </code>echo $$'''; cat .env<code>deny,</code>cat $$'''<code>etc., plus a zsh-reading property over$-run lengths 1..4 before '`.

Major 2 — $${ hides the rest of the line in bash [deterministic fact; pre-existing on next; same $-run code this PR rewrote] echo $${; cat .env, echo x$${ ; cat .env, echo $${x; cat .env are all exit 0 on head and base; real bash 3.2 prints the file (bash reads $$ as the PID, then { as a literal; a lone ${ would be a syntax error so the shell never gets there). The tokenizer takes the second $ of $$ with the { as a ${…} opener and skips to a } that never comes, so everything after is data. This is the same desync class: the PR pairs $ runs left to right before ' but not before { (or (; echo $$(; … is not exploitable because bash errors).
Fix: make the ${ test pair the run the same way (only the last $ of an odd-length run opens ${), and add rows for $${, $$$${ , x$${.

Minor 1 — USER-GUIDE and PR body overstate the zsh model [deterministic] docs/USER-GUIDE.md (hook line: "read the way bash and zsh each read it") and PR body ("$$'x' stays the PID and a plain quote"). Both are false for zsh until Major 1 is fixed. Fix with the code, or state the exclusion.

Minor 2 — tests pin echo $$'x\'; ls as allowed without a zsh counter-row [deterministic] The pinned allow encodes bash semantics only; after Major 1 add the zsh-reading expectation explicitly so the divergence is documented, not accidental.

Minor 3 — PR template: macOS not ticked though the zsh behaviour is the macOS tool shell [judgment] The author ran zsh 5.9 in an Alpine container; the Platforms line lists Linux only. Tick or explain.

Nit 1 — "Other fuzz-found allowed commands" are not attributable to this PR [judgment, informational] The token-soup fuzz also found pre-existing allowed shapes involving >()# comment forms, $((") in zsh, and heredoc-in-paren forms (e.g. \<nl>>()#; cat .env); next allows all of them and #5309 owns the findParenClose area. Listed for the maintainer, not blocking this PR on their own.

CI

Head 5565152b90ef23d830fd632c60d792d5b4291af4. Latest-per-name check runs: no check with a conclusion other than success/skipped (42 registrations: 39 SUCCESS, 3 SKIPPED). action_required runs for this head sha: none. Matrix executed. Branch BEHIND next: rebase required before merge.

Verdict

Changes requested. 2 Major (live bypasses in the guard's own bug class, verified on real zsh/bash), 2 Minor, 1 Minor (template), 1 Nit. Substance of the ANSI-C decoding is otherwise clean.

@0xdhx

0xdhx commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the re-review. Both Majors are fixed, along with four more places that read a run of $ the wrong way. The branch is rebased onto next (755bf26f0; no conflicts, including over #5478), and the description is updated.

Merge order with #5309. The two PRs conflict in this hook. This PR lands first. #5309 rebases onto it afterwards and carries the ANSI-C reading into its new readers (skipQuoted, countParenClose, scanCommandParenClose). #5309's description will gain Depends on #5285 in its next revision, and I've posted the same order on #5309.

Findings → commits

  • Major 1 (zsh opens $'…' after a run of $) → cdd3d2ec7, plus rows in 485c4ca2d. A new opensAnsiC decides, per reading, whether the quote after a run opens a span. Under zsh's reading any run does: zsh 5.9 prints the PID then A for $$'\x41'. Every reader that ends a $'…' span uses it: tokenize, findParenClose, the heredoc-tag reader, and the ${…} end. That last one covered two shapes the review didn't list, which zsh also reads. In echo ${x:-$$'}'}; cat .env, the span's } doesn't close the brace. And a heredoc tag isn't expanded, so the tag of <<$$'EF' is $EF in zsh ($$EF in bash). The rows you asked for are in: echo $$'\''; cat .env and cat $$'\'' .env are denied, and a zsh-reading property covers runs of 1 to 4 $ before an escaped quote, in a word, inside "$( )" and inside ${…}.
    • Your open sub-decision: deny. Under zsh, echo $$'x\'; ls is an unterminated span, and zsh 5.9 refuses the line (unmatched '). The zsh reading has nothing to check, so the guard denies it as ansi-c-unterminated, the same as any span that never closes. Acceptance criterion 6 now holds for bash only. The description says so, and the cost is listed under Stated costs.
  • Major 2 ($${ hid the rest of the line) → b997abe89 and d935de487. I adapted your fix. Pairing the run left to right is right for bash and dash, but outside double quotes zsh's lexer still opens a ${…} after a run of any length, and splits it into PID and text only when it expands it. So echo $${x #}; cat .env reads the file in zsh, while bash and dash read a comment. My first commit paired the run in every reading. That allowed this command, which next denies. The round's own executing review caught it before anything was pushed, and d935de487 fixes it: only bash's reading pairs the run, zsh's keeps the brace rule, and a command holding $${ or $$( now gets all three readings. The plain reading is the pre-bug(hooks): secret-read-guard has no model of ANSI-C quoting — echo $'x\'y'; cat .env and cat $'.e\x6ev' are allowed #5255 scan again, so a command without $', $", $${ or $$( still gets exactly the guard next has. Rows: $${, x$${, $${x, $$$${, a continuation inside the run, the forms inside $( ) and "$( )", five zsh brace shapes, and a property over runs of 1 to 6.
  • Self-found, same rule, also allowed on next:
    • 216b58b48: inside "…", zsh and dash pair the run too. echo "$$(" ; cat .env reads the file in both; bash reads $ and a $( that never closes. The pairing is in zsh's reading of both double-quote loops. The findParenClose one decides cat <(echo "$$(") .env, which bash and zsh both read.
    • f2a979c52: in an unquoted heredoc body, every shell pairs the run. So $$( ' $(cat .env) ' ) runs the inner $(cat .env), and the body scanner had skipped it as quoted.
  • Minor 1: the description's $$ bullet and the USER-GUIDE line now state zsh's rule. Both are true of the code as pushed.
  • Minor 2: echo $$'x\'; ls, and the same shape with a line continuation inside the run, are now deny rows named zsh reading. Each says that bash would allow it.
  • Minor 3: I ticked macOS. tests/gsd-secret-read-guard.test.cjs is on the macOS conformance tier (scripts/lib/macos-conformance-tier.generated.cjs), so conformance test (macos-latest, 24) runs it. The zsh checks ran in zsh 5.9 (Alpine), not on a macOS host, and the Platforms line says so.
  • Nit 1: left alone. I agree those shapes belong to fix(#5267): keep a case pattern's paren from closing a substitution in the secret-read guard #5309's findParenClose work.

Found while testing, not fixed. Each of these is also allowed on next:

  • The text inside ${…} ends at its first } outside a $'…' span, whatever plain quoting or nesting comes first. echo ${x:-'}'}; cat .env #' is read by bash, zsh and dash. This is the most serious one.
  • A heredoc tag that holds $( ) or a backtick.
  • An arithmetic << (((x=1<<2))), which is read as a heredoc.
  • A heredoc terminator line after a line ending in \, which the shells join to it.
  • A backtick body is scanned without the backslash removal the shells apply to it first. echo `$$$$\$\<newline>${$$$; cat .env` is read by bash and dash (not zsh). The backtick scanner is otherwise untouched by this PR.

The round's generated commands found 19 more misses of these kinds. I can file them all as one issue, or fold the ${…} one into #5309 if you'd rather it go with that span reader. Your call.

Verification

  • Shell checks: every new deny row was run in its shell (bash 5.2.21, zsh 5.9, dash 0.5.12) with the protected file present, and printed it.
  • Commits: the branch's 17 commits are scoped and bisectable. At each of the 16 before the tip, no test that runs at the tip was skipped.
  • Reversion controls: each of the round's 12 fix sites, reverted alone, fails named tests. Two sites were silent on the first run and got rows (485c4ca2d, and the <( ) row).
  • Negative control: against next's hook, 114 of the 663 hook-level tests fail; on this branch all 695 pass.
  • Real commands: over 514,875 distinct agent Bash commands, nothing next denies is allowed. Compared with this PR's previous head, no verdict changed.
  • Generated commands: over 1,000,000 commands built from $ runs, quotes, braces, #, heredocs and \$, nothing next or the previous head denies is allowed.
  • CI: 42 check-run registrations on f2a979c52 as of 05:04Z, after the description edit re-ran the body checks: 39 SUCCESS, 3 SKIPPED, none failed.
  • Required checks, run locally: lint, the three coverage shards with integration/security/install/slow, the targeted lane, QA, security, changeset, docs and plugin checks all passed.

@0xdhx

0xdhx commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

One more commit since my last reply, and corrections to two things that reply said. The description is updated to match.

e2d3f8ec7: bash pairs a run of $ inside "…" too. My last reply said of 216b58b48 that bash reads "$$(" as $ and a $( that never closes. That was the wrong mechanism. bash's parser pairs the run inside "…" as zsh and dash do ("$$(x)" is the PID and (x)), so the "…" ends at its own ". bash then fails a word holding $$( with no ) at expansion and abandons the rest of that command (the read after the ; does not run), but a read on the next line still runs. After $${, no reading paired both runs: zsh's opens a ${…} there, the plain reading pairs neither, and bash's did not pair the quoted one. So echo $${"$$(" ; cat .env (read by dash) and the same line with a newline for the ; (read by bash and dash) were allowed, as they are on next. bash's reading now pairs the run in both double-quote loops, and two deny rows pin the two commands.

  • Reversion control: reverting the tokenizer's loop alone fails both new rows. Reverting the $( ) end finder's loop alone fails nothing. Over the corpus and my generated commands it changes no verdict, and an independent 1,000,000 from a different generator found six changes, three in each direction, none of which reads the file in any shell. It is kept so the two loops read "…" alike, which the commit message also says.
  • Against next: over the 514,875 corpus commands and 1,000,000 generated commands, nothing next denies is allowed. Against the previous head, no corpus verdict changed. The description's verification section has the rest. That includes two commands the previous head denied and this head allows that dash reads. Both are form A of bug(hooks): secret-read-guard's tokenizer misreads nine shell constructs (${x-$(cat .env)}, a CR before #, <<{, ((x=1<<2)) and others) and allows the cat .env each one hides #5523, next allows them too, and the previous head caught them only through its wrong pairing.

Correction: the five gaps under "Found while testing". I offered to file them. Checking each one against the open issues and the shells changed the picture:

That replaces my offer to fold the ${…} one into #5309: it is #5523's form A, and the change that closes it is the branch #5518 also needs.

Verification for this round

  • Tests: 697 of 697 pass at e2d3f8ec7. Against next's hook, 116 of the 665 hook-level tests fail (the earlier 114 plus the two new rows). Against the previous head's hook, exactly the two new rows fail.
  • Commits: the branch's 18 commits are scoped and bisectable. At each of the 17 before the tip, no test that runs at the tip was skipped.
  • Shell checks: both new rows were run in bash 5.2.21, dash 0.5.12 and zsh 5.9 with the protected file present. zsh rejects both lines and runs nothing.
  • Required checks, run locally: lint, the three coverage shards with integration/security/install/slow, the targeted lane, QA, security, changeset, docs and plugin checks all passed.
  • CI: 48 check-run registrations on e2d3f8ec7 as of 10:06Z, after the description edits re-ran the body checks: 45 SUCCESS, 3 SKIPPED, none failed.

@trek-e trek-e left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — fix(#5255): read ANSI-C quoting ($'…') in the secret-read guard

Head reviewed: e2d3f8ec71f3d5875aa5f41656871c61df5dba48 (fresh gh pr view this turn: mergeable=MERGEABLE, mergeStateStatus=BEHIND, reviewDecision=CHANGES_REQUESTED). Third review. Prior trek-e reviews: c444497f861b, eba8ddee82a9, 5565152b90ef; author replies of 2026-10-15, 10-18 05:15Z and 10-18 10:08Z read in full.

Summary

Every finding from the 5565152b9 review is fixed on this head, re-verified by running the real hook against real bash, zsh and dash: Major 1 (echo $$'\''; cat .env), Major 2 (echo $${; cat .env), Minors 1-3 and the Nit. The author also fixed several $-run shapes beyond the review ("$$(", unquoted heredoc bodies, $${"$$("), and I confirmed each now denies. Differential fuzzing of the head hook against the next hook and three real shells found 0 regressions (nothing next denies is allowed). It did find one live bypass in the PR's own bug class: the "plain" (dash) reading does not pair a $ run, so echo $${$'\''}'; cat .env is allowed and dash reads .env. It is also allowed on next. Changes requested: 1 Major, 1 Minor.

Classification & gate compliance

  • Issue #5255: labels bug, confirmed-bug (Bug-Fix track). PR label review: changes requested. Not draft. Title fix(#5255): … conventional. Branch fix/5255-secret-read-guard-ansi-c-quoting conforms. Body has Fixes #5255, Fix template. 19 commits, all conventional fix|test|docs|chore|refactor(#5255).
  • Changeset .changeset/quiet-foxes-quote.md: type Security, pr: 5285, bold lead, trailing (#5255). CHANGELOG.md untouched. Security needs no docs/ change; docs/USER-GUIDE.md hook line updated (zsh $$'x' rule is now stated correctly).
  • No references/ or workflows/ file added, so no inventory drift. No new vocabulary, so no CONTEXT.md change. Diff is exactly 5 files: hook, test, oracle fixture, USER-GUIDE, changeset.
  • Platforms: macOS now ticked with an honest note (macOS conformance tier; zsh measured in Alpine). Linux ticked.
  • Scope: one concern. The zsh reading stays in this PR by maintainer ruling (review 3). claude --agent $'gsd-debugger' is tracked in #5441. The "found while testing" gaps are filed as #5523 / #5512 / #5513.
  • Branch is BEHIND next. git merge-tree --write-tree refs/prrev/5285 origin/next returns a clean tree (no conflicts) and none of the hook, test or USER-GUIDE paths changed on next since the merge base. A rebase is needed before merge; this is not a content finding.

Must-Have Acceptance Checklist (issue #5255)

Run through the head hook (exit/verdict from findSecretRead, with the matching shell result):

  1. echo $'x\'y'; cat .env denied: PASS (allowed on next; bash and zsh read .env).
  2. cat $'.e\x6ev' denied: PASS (allowed on next; bash and zsh read .env).
  3. git commit -m $'it\'s' && cat .env denied: PASS.
  4. x=$(printf $'\''); cat .env denied: PASS.
  5. cat <<$'EOF' heredoc then cat .env denied: PASS (author rows plus <<$'E\'F').
  6. Unterminated $' / undecodable escape not allowed: PASS (ansi-c-unterminated, ansi-c-undecodable).
  7. All scanners agree on span ends: PASS by construction (shared span-end rule, seeded parity properties).
  8. Optional item 5, $"…" read as "…": PASS.
  9. echo $'\xff'; ls lenient: PASS (verdict null; cat $'x\xc3/.env' still a read).
    Note: the issue's criteria are about $'…'; the bypass in Finding 1 is a $-run problem, not an unmet criterion, but it is the same desync class and is surfaced below.

Root-cause / functional verdict

Root cause (one sentence): the scanners ended a single-quoted span at the next ' regardless of a preceding backslash and never paired $ with ', so $'…' was neither skipped correctly nor decoded; the diff fixes this in tokenize, findParenClose, readHeredocTag and the ${…} skipper through one shared span-end rule and one opensAnsiC decision per reading. It is a root-cause fix, not a special-case of the five issue commands.

Remaining gap in the same class (Finding 1): a command holding $${ or $$( is scanned under three readings, but the third ("plain") reading is the pre-#5255 scan, which opens a ${…} at the second $ of $${, whereas dash pairs $$ as the PID and reads { literally. When the same line also holds a $'\'', bash/zsh readings treat it as ANSI-C, the plain reading treats the ${ as an unterminated brace, and nobody models dash's actual reading.

Blast radius

hooks/gsd-secret-read-guard.js runs on every Read/Grep/Bash PreToolUse. New exports { decodeAnsiC, findSecretRead } (test seam only; no existing consumer imports the hook). Scanner signatures gain a trailing reading parameter; all call sites are inside the file. findSecretRead returns {code,text}|null uniformly. Over 105k differential commands (below) the head hook denied every command next denies (0 regressions); head additionally denies about 2,000 + 3,100 commands that next allowed and a real shell executes as a read. CodeGraph index in this checkout predates the PR symbols, so blast radius came from reading the diff and executing the hook.

Co-change completeness

Hook + test + oracle fixture + USER-GUIDE + changeset all present. hooks/hooks.json, scripts/build-hooks.js, install-tree fixtures name the hook only, so nothing is missing. No gap found.

Test quality

  • Regression tests: 697 hook-level and decoder tests per author; author reports 116 of 665 hook-level tests fail against next's hook and all pass on head. Plausible and consistent with my base/head table (every issue command and every new $-run command is allowed on next, denied on head). I could not execute the suite (local node --test is blocked; CI is the executor).
  • tests/gsd-secret-read-guard.test.cjs: no wall-clock assertions, no source-grep (the oracle is a JSON fixture loaded as data), fast-check properties present with seeds ($-run property at 60 and 40 runs, decoder properties at 2,000, parity at 25). Boundary rows at 1 MiB -1/0/+1 and nesting depth 2/3/4 are present. Reversion controls for the round's 12 fix sites are reported by the author.
  • Gap: no row for the dash-only shape in Finding 1.

Memtrace Evidence

  • get_impact: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT).
  • get_symbol_context: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT).
  • recall_decision: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). No governing-decision check ran; CLAUDE.md/CONTEXT.md/CONTRIBUTING.md predicates were checked manually.
  • find_code_review_issues: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). The deterministic review pass did not run; findings below are manual and executed-evidence based.

Substitute evidence (CodeGraph had no index covering the PR's new symbols, so none was used): detached worktrees of the PR head (e2d3f8ec71) and origin/next (0dd1845550); the head hook loaded in-process, the next hook loaded from a copy with the stdin listener stubbed and a findSecretRead export appended; each command run through real bash --norc, zsh -f (5.9) and dash in a scratch directory holding a marker .env, flagged BYPASS when a shell printed the marker and the head hook returned null. Runs: 9 hand-built shapes and 11 targeted shapes (all review and author claims), 45,000 token-soup commands (3 seeds, heredoc, backtick, <(), (( included) and 80,000 $-run / quote / brace commands (4 seeds, no heredoc or backtick). Result: 0 regressions against next. 56 + 27 allowed-but-executed commands, all also allowed on next: the first batch are the #5523 / #5513 families (heredoc tags holding ${ or a backtick, <()/>() comment forms, zsh $(($"))); the second batch are all dash-only and all contain a $'\'' next to $${, $$( or ${x:- (Finding 1). Shell versions on this host: zsh 5.9, bash 3.2.57, dash (macOS /bin/dash).

Findings

Major 1 — dash pairs $$ before {, but no reading models that together with $'…' as a plain quote, so echo $${$'\''}'; cat .env is allowed and dash reads .env [deterministic fact; also allowed on next; same $-run desync class this PR rewrote] hooks/gsd-secret-read-guard.js (the plain reading's ${ test in tokenize, findParenClose and the ${…} skipper for a command that holds $${ or $$().
Repro (head verdict null, next verdict null; dash prints <pid>{$\}# then the marker):

echo $${$'\''}'; cat .env
echo $${x:-$'\''' ; cat .env #
echo $'}'$${$'\''' ; cat .env #
echo "$$("x$'\''${$'<newline>cat .env

dash: $$ is the PID, { is literal, $ is literal and '\'' / '}' are plain quotes, so cat .env runs. bash fails the line (unterminated quote), so the bash reading is right to see nothing; zsh opens ${…} at the run, so it too sees no read. The plain reading opens ${ at the second $ of $${, finds the $'\'' plain quotes close, and the } sits inside the next plain quote, so the brace never closes and the rest of the line is data. The PR's own text says the plain reading "pairs neither" run and is the pre-#5255 scan; that is what leaves this gap. 27 of 80,000 commands in a quote/brace-only generator hit it; all 27 are dash-only.
Fix: make the third reading the dash reading: pair a $ run left to right before { and ( as bash's reading already does, with plain quotes (no $', no $"), and keep it scanned whenever a command holds $${ or $$( (the existing trigger). Add the four rows above as deny rows named dash reading, plus a property over $ run length 1..6 before { followed by $'\''.

Minor 1 — branch is BEHIND next [deterministic fact] mergeStateStatus=BEHIND. git merge-tree --write-tree against origin/next (0dd1845550) is clean and none of the PR's paths changed upstream since the merge base. Run git fetch origin && git rebase origin/next and push. Note #5309 lands after this PR by the author's stated order; no content change needed here.

CI

Head e2d3f8ec71f3d5875aa5f41656871c61df5dba48. Latest-per-name collapse of the check runs: no check with a conclusion other than success or skipped (48 registrations per the status rollup: 45 SUCCESS, 3 SKIPPED). action_required workflow runs for this head sha: none. The matrix executed. No pending run IDs to report.

Verdict

Changes requested. 1 Major (live dash-only bypass of the guard's own bug class, verified in a real shell, present on next too), 1 Minor (rebase). All prior findings and the issue's acceptance criteria are satisfied on this head; substance is otherwise clean.

@0xdhx

0xdhx commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Three commits since the review, on a branch rebased onto next, and two corrections to what the description said before. The description is updated to match.

Major 1 (dash's reading) → 16227f7e4, with one change to the prescription. dash pairs a run of $ before { and ( the way bash does, and has no $'…', so no reading saw what dash runs. I added dash's reading as a fourth reading rather than turning the plain reading into it. The plain reading is the scan next runs, and it still runs last. So a command that next denies stays denied by construction (with the stack-overflow exception below), with no need to show that dash's pairing never clears a command the plain reading denies. The four commands in the review are deny rows named dash reading, with three more deny rows, three allow rows, and the property you asked for over run lengths 1 to 6 before { followed by $'\''. Each of the commit's four changes, reverted alone, fails 8, 7, 8 and 1 of those 11 rows. 3fd90e33b before it only renames the scanners' ansiC parameter to reading, since one of the readings is no longer an ANSI-C one.

Minor 1 (rebase) → done. Rebased onto 184255fbc without conflicts. docs/USER-GUIDE.md is the only file in this PR that next had also changed. The hook's tests passed on the rebased branch before this round's commits (697 of 697). next has since moved four commits (to bd5190448), none touching these files, and the merge is clean. I'll rebase again before merge or when you ask.

Found while reviewing the round → 7d30d2cf4. echo "$\<newline>(cat .env)" runs the read in bash and dash, because both join the continuation and then open the $(. The same holds in an unquoted heredoc body. Every reading missed it, and next allows it. bash's and dash's readings now open the $( there. zsh's reading opens it in a heredoc body only, since inside "…" zsh keeps the text. No reading opens it in a heredoc tag word, which is not expanded (dash reads <<E"$\<newline>(" as the tag E$(). A $ before a \<newline> is now one of the tokens that turn on the extra readings. 20 rows pin it: 12 deny rows, 7 allow rows and a property. Each of the fix's six pieces, reverted alone, fails 7, 8, 1, 3, 2 and 2 of them.

Correction 1: "nothing next denies is allowed" is false for deep nesting. The plain reading's verdict is unchanged only up to the depth at which the parser overflows the stack, and an error in the plain reading allows the command under the hook's crash policy. This PR overflows at a smaller depth than next. Measured with the real hook process on cat .env; followed by "$( repeated: next denies it up to about 16,800 characters, this PR before this round up to about 14,200, and this head up to about 13,200. So a command of roughly 13,200 to 16,800 characters of that nesting is denied on next and allowed here. 7d30d2cf4 lowered the threshold by about 1 KB, and the earlier rounds account for the rest. No real command comes close: none in my corpus holds more than 107 $(, and none overflows even with a sixth of the default stack. Denying when the final plain scan throws a RangeError would close the window on any Node version. That is a small change, but it reverses the declared fail-open policy for one cause, so I have not made it. Should I add it here, or leave it for a follow-up?

Correction 2: the description's third-round corpus note. It said that one newly denied command passed a bash -c string to a shell function as data. It is a true positive: its last line runs bash -c "cat \$'.e\x6ev'", which reads the file, and next allows it.

Stated cost. The worst shape I found is a 1 MiB run of $$(. It takes about 9.6 s at this head, against 4.3 s before this round and 0.015 s on next. The hook timeout is 120 s, and the slowest real command takes 5 ms.

Verification for this round

  • Tests: 728 of 728 pass at 7d30d2cf4. Against next's hook, 136 of the 696 hook-level tests fail. Against the previous head's hook, 18 tests fail, all of them added this round.
  • Commits: the branch's 21 commits are scoped and bisectable. At each of the 20 before the tip, no test that runs at the tip was skipped.
  • Review: a reviewer that started with no prior context ran four passes over this round's commits, executing commands in bash 5.2.21, dash 0.5.12 and zsh 5.9. The first found the continuation bypass, and a commit message claiming that a clause could not change a verdict. The second found a zsh bypass and a false block, and the third a dash bypass through a heredoc tag word and a false block, both in earlier versions of the fix. Each is fixed and pinned by a row. The fourth, a re-run of its differential and the corpus at the tip, found nothing. Its last differential, about 237,000 generated commands, found 56 that the previous head denies and this head allows. All 56 are syntax errors in bash and dash, and no shell reads them.
  • Corpus: over 527,357 distinct real agent Bash commands (static analysis, nothing executed), none that next denies is allowed, and no verdict differs from the previous head. Three are allowed on next and denied here. The previous head denied the same three: two ansi-c-unterminated commands and the true positive above.
  • Required checks, run locally: lint, the three coverage shards with integration/security/install/slow, the targeted lane, QA, security, changeset, docs and plugin checks all passed.
  • CI: 42 check-run registrations on 7d30d2cf4 as of 15:06Z, after the description edit re-ran the body checks: 39 SUCCESS, 3 SKIPPED, none failed.

@trek-e trek-e left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review — fix(#5255): read ANSI-C quoting ($'…') in the secret-read guard

Head reviewed: 7d30d2cf4357a6267400ab70677b2322e7057a84 (mergeable/mergeStateStatus reported UNKNOWN while GitHub recomputed; previously BEHIND). Prior review was on e2d3f8ec71; the author replied 2026-10-20T15:39Z with three commits (16227f7e4, 3fd90e33b, 7d30d2cf4) plus a rebase. CI at this head: 31 distinct check names, latest-per-name all SUCCESS or SKIPPED; no failing check and no action_required runs.

Method: the head and next hooks were loaded in-process and ~100k generated commands (five token mixes: line continuation, $', $", $$ runs, $((, backticks, heredocs) were run through real bash 3.2, dash and zsh 5.9 in a sandbox holding a marker .env. No node --test was run.

Prior findings

Finding Status
Major 1: the dash reading (echo $${$'\''}'; cat .env, echo $${x:-$'\'' ; cat .env #, echo $'}'$${$'\'' ; cat .env #, echo "$$("x$'\''${$'<nl>cat .env) allowed FIXED (all four return secret-read)
Minor 1: BEHIND next PARTLY OPEN: rebased onto 184255fbc, next is now 9 commits ahead (none touch the hook, test or USER-GUIDE)
Issue #5255 acceptance rows (echo $'x\'y'; cat .env, cat $'.e\x6ev', …) PASS (executed, all deny)

Summary

The prior Major is fixed and the differential found no regression except one live fail-open. Changes requested.

Findings

Major

  1. Fail-open on RangeError lowers the deny threshold below next: a command next denies is now allowed. hooks/gsd-secret-read-guard.js:2011 (the outer catch { crash(ON_CRASH…) }, ON_CRASH=ALLOW at :146). On Node 26.11.1 with findSecretRead('cat .env; ' + '"$('.repeat(n)), the last non-throwing length is 15,232 characters on head and 20,827 on next. At 5,400 repetitions (16,009 characters) next returns .env (deny) and head throws RangeError, which the hook turns into allow. The author acknowledges the regression and asks whether to fix; it is a defect of this diff. In main() (and the plain-reading call in findSecretRead) treat RangeError as a deny (command-too-complex), or make the nested-$( scanner iterative, and add a test at a depth that overflows on the CI Node.

Minor
2. Quadratic cost on $$( runs: 1 MiB of $$( takes 7.3 s per Bash call (3 s for $$$() against 11 ms on next (the author measures 9.6 s). The hook runs on every Bash call, so a pathological command stalls the session, and the cost is stated only in a PR comment, not in the file's "Documented gaps" header. Bound the run scan (cap the run length, deny command-too-complex above it) or make the pairing linear, and document it.
3. Still allowed in every reading (each read in a real shell, and also allowed on next): cat ${x:-$'.env'}, cat ${x-$'.e\x6ev'} and cat ${x:+a}$'.env' (bash and zsh print the file); heredoc tags holding ${ (<<${;cat .env, <<$'E'${; cat .env #, <<}; cat .env #, $(E<<\n)\ncat .env); and zsh $(($"))-style arithmetic (x$(($"));cat .env, $((")); cat .env #) and ; cat .env${}. The changeset and USER-GUIDE promise "judged by the value bash and zsh each decode". Close these in this PR (deferral is not permitted) or list the exact residual shapes in the hook header's "Documented gaps" so the promise is not broader than the behavior.
4. Branch needs a rebase: git fetch origin next && git rebase origin/next; the new sha needs a fresh verification pass.

Nit
5. .changeset/quiet-foxes-quote.md does not mention the dash reading added this round and states $$-then-{ only as "the PID, then a literal {".

Clean checks

The head-versus-next fuzz (~54k commands) found no regression other than finding 1. Tests have no wall-clock assertions, no source reads of the hook, seeded fast-check properties (25-2000 runs), 1 MiB-1/1 MiB/1 MiB+1 boundary rows and nesting rows at depths 2/3/4. Gates (carried): Fixes #5255, confirmed-bug, changeset Security pr: 5285.

Memtrace Evidence

The Memtrace monthly quota is exhausted for this run, so none of the graph tools below were executed. These are not-run, not passed; findings come from the isolated manual review only.

  • get_impact: not run (quota exhausted). Blast radius and risk tier were not computed from the graph.
  • get_symbol_context: not run (quota exhausted).
  • recall_decision: not run (quota exhausted). The recorded-decision gate did not execute.
  • find_code_review_issues: not run (quota exhausted). The deterministic review pass did not execute.

Verdict

Changes requested. Every finding above must be resolved before approval; a rebase clears base drift only and retires none of them. This review does not release or re-run CI.

@trek-e

trek-e commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

Heads-up on a change to how fixes land in next, and what it means for this PR. Nothing here blocks you today; it is how to be ready.

1. No need to keep merging next into your branch. next now uses a GitHub merge queue. Once a PR is approved and green, it is queued, and the queue re-tests it on top of next plus whatever is ahead of it, then merges. "Update branch" is no longer a prerequisite to merging, so please skip it unless you have a real conflict or want a fresh signal.

2. Add a Self-test report (fix PRs). Fix PRs now carry a short ## Self-test report section with a JSON block, in the fix template. It records the failing-first regression test, the commands you ran, your OS/Node, and a passing full-suite run, bound to your final commit sha. It is a head start for the reviewer, not proof; CI on the queue commit is authoritative. The advisory Self-test report check on this PR shows what is missing. Fill it in after your last push (a new push makes the sha stale).

3. Review. The advisory Reviewed or maintainer check is red until someone with write access approves. That is expected and resolves with a review, not with a change from you.

4. CI cost. Windows/macOS lanes now run on the queue, not on every push. If your change is platform-specific, add the full-matrix label to get them on the PR.

Questions are welcome here. Details: docs/branch-protection.md.

Posted at the maintainer's request.

0xdhx and others added 22 commits November 5, 2026 02:07
… as the issue requires

Issue open-gsd#5255 lists `echo $$'x\'; ls` as its allow control: `$$'x'` stays the PID
and a plain quote. zsh's reading opened a span there and denied it as
`ansi-c-unterminated`. A span after an even run now denies nothing for want of
a value, unterminated or undecodable; the bash reading judges the command, and
any read either reading sees still blocks. An odd run keeps the deny: that span
is `$'…'` under both shells.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…tainer's scope ruling asks

Major 2 of the 2026-10-28 review removes the `$( )` end grammar from this PR,
and the 2026-11-05 ruling keeps only what the four Major 3 shapes need. None of
them needs it: taking out the comment rule with its arithmetic and operator
tracking and the `$[` arm that only kept a `#` out of it reds three rows, two
comment shapes in `$( )` and the `$[` budget row. A `$( )` is ended as the plain
reading ends it again, so `echo $(#)'<newline>); cat $'<secret>' #'` is allowed,
as it is on `next`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…eredoc tag

`<<}; cat <secret> #` runs the read in bash and dash, and `<<{` in zsh too. The
scan took the brace as a group, so the read's `cat` became the tag and the name a
bare command word. dash also ends a tag whose `${` never closes at the next
metacharacter, where the scan took the rest of the line as the `${`. A brace
where a tag is due is now the tag, and that `${` is its text. The earlier tag
reading keeps the old grouping, so the open-gsd#5513 pass denies what it denied. Major 3
of the 2026-10-28 review, with the ruling of 2026-11-05 to close it here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…quote

zsh takes `$(( ))`, `(( ))` and `$[ ]` as arithmetic first, and a `"` there, or a
`'` in a `$[ ]` inside a ${…} that is not expanded, opens nothing. So
`x$(($"));cat <secret>` and `echo ${x+$[ } ' ]}; cat $'<secret>' #'` run the read,
where every reading took the quote as open to the end of the line. Under zsh's
reading those spans now end at their bracket. A `$(( ))` must end in `))`, and
takes this path only with a `"` in it, so a `$( )` holding a subshell reads as
before. A command with no other trigger holding a `"` and `((` or `$[` now gets
the readings. Each opener's reader charges its steps, so unclosed `$[` end in
`command-too-complex`.

zsh alone reads these (5.9, Alpine 3); bash and dash fail each line. No shell
reads through a `$[` directly in a `$( )`, so that site has no arm. Major 3 of the
2026-10-28 review, with the ruling of 2026-11-05 to close it here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…ne continuations

dash removes `\<newline>` before it reads a `${…}` within "…", and takes a `'` after a
pattern operator as a quote there. The dash reading looked for the operator in
the raw text, and for `${` as two adjacent characters, so a continuation before
either hid it, the `'` read as text, and the "…" ended elsewhere: dash runs the
read in `echo "${x\<newline>%'"'$${y}'}" $'\'; cat <secret> #'`. Both now step over
continuations, and a `$` before a continuation opens that `${` under bash and
dash, as it opens a `$(`. zsh keeps such a `$` as text within "…". Major 3 of the
2026-10-28 review, with the ruling of 2026-11-05 to close it here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
… in the glob itself

The outer catch turned any RangeError on the Grep path into `glob-too-complex`.
Only expanding the glob's braces can overflow, so the deny now wraps that call
and nothing else, and any other Grep error takes the crash policy. Nit 11 of the
2026-10-28 review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…pen-gsd#5513's earlier reading

readEachWay swallowed every error that was not a limit, so a TypeError in a decoder
silently turned that reading off. It now goes to the crash policy, after the plain
reading has had its say, whose verdict still stands. A secret read found by any
reading now outranks a word with no value to check, so the block a command gets
does not hang on which reading ran first. ansiCTagWord ignored open-gsd#5513's earlier tag
reading, so that pass added nothing for a tag holding a `$'…'` span; it now ends
such a tag at any whitespace. Minors 5 and 7 of the 2026-10-28 review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…owed against a frozen scanner

The claim rested on construction and on a corpus outside the repo. `next`'s scanner
is now a fixture, frozen before open-gsd#5255, and a seeded property runs 20,000 commands from
a token soup of the shapes the readings change through both, in process: every
command the frozen scanner denies, the hook denies. It denies 1,091 of them, and the
test fails if a change to the soup drops that under 1,000, since a soup it never
denied would pass. Minor 6 of the 2026-10-28 review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
Comments and test names cited review rounds, which mean nothing once the change
is merged. They now say what the code does, and the second-look scan is named
for what it is: the scan with the `${…}` reader off. Nit 8 of the 2026-10-28 review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…geset

The user-guide line carried budget arithmetic and every reading's rule, and the
changeset was one long sentence. The line now says what a user can rely on and
what is not covered, and the changeset leads with the symptom. Nit 9 of the
2026-10-28 review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…their fixes are reverted

Reversion controls found both silent. The tag row was read again by a nested
rescan, so it now sits at the nesting limit, where only the `$( )` reader decides.
The property's token soup never reached a command only the plain scan denies, so
dropping that scan passed it; it now shares the tokens of the 300,000-command
corpus, on which that mutant regresses 141 commands, and fails on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
Reading such a `${` as tag text means tokenize goes on past a failed search for
its `}`, so a run of them searched the rest of the command each time: 1 MiB of
`<<${ ` took 4.6 s of CPU, where `next` takes 0.04 s. No `}` can appear after a
search that found none, so one failed search now answers the rest: 0.37 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…which no Major 3 shape needs

Major 2 removes the out-of-scope work, and the 2026-11-05 ruling keeps what the
four Major 3 shapes need. With this arm off, nine rows and a property of its own
fail and no shape row does, so it goes, with its trigger. `echo "$\<newline>(cat
<secret>)"` is allowed again in bash and dash, as it is on `next`. That shape and
the comment shape dropped earlier are pinned and listed in the header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…, so nothing denied is allowed

Reading a brace, or a `${`, as a heredoc tag broke the claim that nothing denied
before is allowed: `: <<} E$(x y)F<newline>E$(x y)F<newline>cat <secret>` was denied
and became allowed, because no pass still paired the brace as a group with a tag read
as a word. No shell reads these. A command that meets such a tag now gets both `$( )`
span readings with the brace read the earlier way, and the debugger-spawn check reads
it both ways too. A `${` where a tag is due is now its text to dash and the plain
reading whatever follows, so `: <<${;cat <secret> #}` is closed with `<<${;cat
<secret>`, and it searches nothing, which replaces the cache for a failed search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…denial to it

Rethrowing a reading's error from readEachWay unwound the outer plain scan, so with
a nested span in fault `echo $(echo $'…'); cat <secret>` lost its denial, and the
crash policy is a silent allow, so the rethrow gave no signal anyway. The first bug
is now recorded, the scans run on, and the hook writes it to stderr only when it
would otherwise allow. Minor 5 of the 2026-10-28 review, reworked after the round review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…very depth

The ranking compared readings at one level, and scanCommand returned the first
nested hit whatever its code, so `\`echo $'a\`; cat <secret>` named the span. A
nested word with no value is now held and returned only if the scan finds no read.
Minor 7 of the 2026-10-28 review, reworked after the round review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…at a skipped body runs

The header's shape was a quote in zsh's `$((`, and the reader took only `"`, so
`((x' ));cat <secret>;''` still ran the read in zsh. It now takes either, and so does
the trigger. A `$[ ]` or `(( ))` body skipped under zsh's reading can hold a
substitution zsh runs, so those are scanned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…at a `((` costs

The budget comment named a function that is gone, and the two sentences saying nothing
denied before is allowed now say how a brace tag keeps that true. A quote beside `((`
brings a command under the readings, so it reaches the budget as a `$'\t'` does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…ted scan meets

The gate on `<<` tested the outer text, so `eval ': <''<${x:-a b}…'`, which bash and
zsh run and `next` denies, returned before the flag was read, and was allowed. The
two passes now hang on the flag alone, which holds through a pass. Each of them gets
a row of its own, the property's tokens reach this form, and a quoted `}` in a tag,
which only the triggered readings read and `next` allows too, is pinned. A substitution
in `(( ' … ' ))` gets a row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…ner enters a body

The ranking lived in scanCommand, but scanShellInterpreter, scanXargsPipe and
scanGitConfig each stop at their first hit, so `bash <<A <<B` with the span in the
first body and the read in the second still named the span. The word is now held in
readEachWay, the one way into a nested body, and reported only if no read is found.
The comment on Minor 5 now says what a fault in a reading does cost: what that reading
alone would have denied at that level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
… or "…" skips

bash and zsh run `x=1;echo ${x+$[ ' $(cat <secret> >&2) ' ]}`, and zsh runs `echo "$[ "
' $(cat <secret> >&2) ' " ]"`, so the `${…}` reader's skip and the one in "…" now scan
the body as the top-level skip does. The `${…}` reader has no list to add to inside a
`$( )`, whose body is read again, so a null one is passed over. `$(( ))` reaches the
`$( )` end finder, so scanning its body means changing how that reader hands a body
over: it is pinned as a gap instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…ually cover

bash, dash and zsh run a substitution beside a `'` in a `$(( ))`, and bash runs one in
`if((' … '));then`, so both are listed and pinned. So are three shapes past what
zshMathEnd takes: a `]` in the body, an odd `$` run in "…" and a `"$[` in a `${…}`. All
five are allowed on `next`. A held word costs the name of a read only a later pass sees,
not the denial. The budget comment now says which passes a brace tag adds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
…ells that run each

The header's arithmetic gap sentence stated classes, and the classes did
not hold. It read an escaped `]` in a `$[ ]` as zsh's alone, though bash
runs it too, and "a `]` in the body" and "an odd `$` run" each took in
shapes that are denied. The examples turn on different parts of the
scan, so no one clause covers them.

So the header now gives examples, each with the shells measured to run
it, and says they are not the whole class. Measured in bash 5.2.21, dash
0.5.12 and zsh 5.9, against the hook at `next` and at head.

`{((`, an escaped `[` in a `$[ ]` and an escaped `(` or `)` in a `(( ))`
join the list and the pinned rows. All are allowed at `next` too.

No change to what the hook does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAKyBQFZxfdRaFhfpKNUd
@0xdhx
0xdhx force-pushed the fix/5255-secret-read-guard-ansi-c-quoting branch from 829406b to 18f963b Compare November 5, 2026 23:12
@0xdhx

0xdhx commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Rebuilt on next as you ruled, and pushed at 18f963b81 on base d47f196d1. The description and Self-test report are refreshed, and the measurements in them were re-run on this code. Two questions at the end.

Majors

  • Major 1: f68ef3ffc. echo $$'x\'; ls is allowed. After an even run, a span zsh would read as unterminated or undecodable is not denied for that.
  • Major 2: comment rule in the $( ) end finder removed in 81635ea6a, and the $ before a line continuation opening $( in 61cb559d2. Neither is needed by a required shape. Both now read the file in a shell and are allowed, as on next, and are pinned.
  • Major 3: all four shapes are denied, and their "Documented gaps" entries are gone. Each has rows that fail before and pass after.
    • <<}; cat .env #: ded44e1c7. fc67167f6 and dadd98731 restore what that reading stopped denying.
    • x$(($"));cat .env and echo ${x+$[ } ' ]}; cat $'.env' #': 0e4c988f9
    • the dash line-continuation form: 44b8870ec

Kept that your ruling did not name. You may want these out, so here they are:

  • The second-look scan. Major 2 listed it. Removing it fails 11 rows, among them reads that run in a shell and that next allows.
  • <<${ tags (f8bae7d74), a ' in arithmetic (827cfe124), bare (( )) and substitutions in arithmetic bodies (c8b2146d7). The four shapes do not need these. They close neighbours that read the file in a shell.
  • The main() refactor, the require.main guard and the exports. Major 2 listed the refactor and the exports. The RangeError arm and the in-process tests need them, and the frozen-scanner test calls the two exported passes.

The step budgets stay under your Minor 4 line. The description's scope table has all of this.

Minors

  • Minor 4: the rows stay, as you ruled, but I did not answer the timing objection. They still depend on a spawn backstop. a megabyte of $$( is denied once the budget is spent takes about 1.3 s at low load here, and failed its 10 s backstop in both full runs one review made at a load of about 45 on 32 cores.
  • Minor 5: fe14b84d6, 1e1b382c7. A reading bug goes to stderr, and it costs no denial another pass can make.
  • Minor 6: 73177dbe9, 37bd5ae43. A frozen copy of next's scanner at d47f196d1, compared over 30,000 seeded commands, with a positive control. That is evidence over a finite grammar, not a proof.
  • Minor 7: fe14b84d6, 4279f471c, a880a9ae0. A tag is read under bug(hooks): secret-read-guard allows cat .env after a $( ) whose heredoc tag holds $(…) or ends in a CR, or (bash 3.2) whose heredoc body holds a ) #5513's earlier reading, and a secret read outranks a word with no value within one scan. A read only the second-look scan or a later pass finds can still lose its name while the command stays denied.

Nits

  • Nit 8: 025c4c129 removes the round citations. Two test comments still mention the review. I'll drop them on the next push rather than move the head for them.
  • Nit 9: b3c5d497c. The guide line goes from 992 words to 616 (399 on next), and the changeset is one bold lead and a short explanation.
  • Nit 10: declined, no code change. These are false positives, as you judged: str is a string, and an out-of-range read gives undefined, which each site compares to a character. Your review says they stand as Nits if you want them cleared mechanically, so say if you do.
  • Nit 11: 43d988f31. A RangeError becomes glob-too-complex only inside the glob's own classification. More than 64 alternatives keeps that code too.

Evidence at this head

  • Fixes were reverted in 27 controls, several on the same mechanism, and each fails named tests. Not every fix or changed line has one, and the description names two that do not.
  • The sequence is scoped and bisectable. tests/gsd-secret-read-guard.test.cjs exited 0 at every commit, and no test was newly unrun at any of them.
  • The 12 checks this host can run all pass locally. macOS and Windows conformance and the coverage merge ran only in CI. One unit shard's first local run was killed by the 600 s chunk watchdog with no failed assertion, under heavy host load, in tests/roadmap.test.cjs, which this PR does not touch. It passed on re-run.
  • As of 00:14Z on 2026-11-06 there are 52 check-run registrations on 18f963b81. The only red with no later success is Reviewed or maintainer. An older Self-test report failure from before the description edit sits beside its successes.

Reads still allowed. Review found more, here and on next, that this PR does not close, and the description lists them. The one to know about is << in arithmetic hiding every later line, which is #5523's form G.

Questions

  1. Your Major 2 asked for an ADR for work moved out. The ruling did not revisit it. Do the two removed shapes need an ADR or sub-issue, or is the pinned list enough?
  2. Do you want any of the kept items above taken out? I'd keep them, and I'll remove any you name.

@trek-e

trek-e commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

Maintainer rulings on your two questions (2026-11-05):

  1. No ADR. File one sub-issue for the two removed shapes (the comment rule in the $( ) end finder, and the $ before a line continuation opening $(). The pinned rows stay, and the sub-issue is where that work is tracked. Link it from the PR description.
  2. Keep all three kept items: the second-look scan, the <<${/arithmetic neighbours, and the main() refactor with the require.main guard and exports. Each one is pinned by rows that fail without it.

The re-review of 18f963b81 follows separately.

@0xdhx

0xdhx commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Done as you ruled. No ADR, and no code moved: the head is still 18f963b81, so the re-review you said follows separately still applies to it.

One finding from measuring the shapes for #5945: #5943's head already denies the plain spelling of the comment shape, and its rebase onto this PR may close the $'…' spelling too. The two heads conflict in the hook, so I have not measured them together. The continuation shape is allowed at both heads, and #5943's rebase plan does not name it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: hooks Session hooks, startup/stop review: changes requested PR reviewed — changes required before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(hooks): secret-read-guard has no model of ANSI-C quoting — echo $'x\'y'; cat .env and cat $'.e\x6ev' are allowed

2 participants